package org.dicr.sys.linux;

import java.io.*;
import java.lang.ref.*;
import java.util.*;

import org.apache.log4j.*;
import org.dicr.sys.*;
import org.dicr.util.crypt.*;
import org.dicr.util.data.exc.*;

/**
 * Класс для работы с файлами формата pap-secrets.
 * <P>
 * Служит для работы с файлами авторизации ppp-пользователей программы pppd. На
 * Unix-системах имеется 3 стандартных файла:
 * <UL>
 * <LI><CODE>/etc/ppp/pap-secrests</CODE></LI>
 * <LI><CODE>/etc/ppp/chap-secrets</CODE></LI>
 * <LI><CODE>/etc/ppp/srp-secrets</CODE></LI>
 * </UL>
 * Соответствующие константы {@link #DEFAULT_PAPFILE}, (@link #DEFAULT_CHAPFILE}
 * и {@link #DEFAULT_SRPFILE} содержат полные имена этих файлов.
 * </P>
 * <P>
 * Файлы имеют следующий формат:
 * 
 * <PRE>
 * name server secret ips
 * </PRE>
 * 
 * , где
 * <UL>
 * <LI><CODE>name</CODE> - имя пользователя;</LI>
 * <LI><CODE>server</CODE> - имя сервера;</LI>
 * <LI><CODE>secret</CODE> - пароль;</LI>
 * <LI><CODE>ips</CODE> - разрешенные IP-адреса.</LI>
 * </UL>
 * Поля отделяются пробелами. Поддерживаются ограничители полей кавычки
 * <CODE><B>"</B></CODE> и <CODE><B>'</B></CODE>, а также escape-символы
 * <CODE><B>\</B></CODE>. Строки, начинающиеся со знака <CODE><B>#</B></CODE>
 * считаются комментариями. Пустые строки и коментарии пропускаются.
 * </P>
 * <P>
 * Каждая запись идентифицируется двумя полями <CODE>name</CODE> и
 * <CODE>server</CODE>. Эти поля могут иметь специальное значение
 * <CODE><B>*</B></CODE>, которое хранится в константе {@link PapEntry#ANY},
 * означает 'любое значение' и имеет внутреннее значение <CODE>null</CODE>. В
 * файле не может быть больше одной записи с одинаковыми значениями
 * <CODE>name</CODE> и <CODE>server</CODE>.
 * </P>
 * <P>
 * Поле <CODE>secret</CODE> представляет собой пароль пользователя. Пустой
 * пароль имеет внутреннее значение <CODE>null</CODE>. Пароли в файле могут
 * храниться как в открытом, так и в зашифрованной (unix crypt) форме.
 * По-умолчанию файл {@link #DEFAULT_CHAPFILE} хранит пароли в зашифрованном
 * виде, а остальные в открытом.
 * </P>
 * <P>
 * Поле <CODE>ips</CODE> представляет собой список разрешенных IP-адресов,
 * представляется строкой, может иметь специальное значение
 * <CODE><B>*</B></CODE>, хранимое в константе {@link PapEntry#ANY}, означающее
 * 'любой адрес' и имеющее внутреннее значение <CODE>null</CODE>, а также
 * значение <CODE><B>-</B></CODE>, хранимое в константе {@link PapEntry#NONE},
 * означающее запрет всех адресов.
 * </P>
 * <P>
 * Данные файла автоматически кэшируются в памяти и автоматически перечитываются
 * только если время модификации файла больше времени последнего перечитывания
 * файла. При модификации файл немедленно сохраняется.
 * </P>
 * <P>
 * Для разделения доступа, класс ведет реестр обьектов. Если обьект, связанный с
 * данным файлом уже существует, то возвращается существующий экземпляр с
 * <CODE>synchronized</CODE> методами доступа, иначе создается новый.
 * </P>
 * 
 * @author Igor A Tarasov &lt;java@dicr.org&gt;
 * @version 1.0
 * @see PapEntry
 * @see "man pppd"
 */
public class PapFile extends SystemFileHandler {

	private static final Logger log = Logger.getLogger(PapFile.class);

	/** Default pap-file name */
	public static final String DEFAULT_PAPFILE = "/etc/ppp/pap-secrets";

	/** Default chap-file name */
	public static final String DEFAULT_CHAPFILE = "/etc/ppp/chap-secrets";

	/** Default srp-file name */
	public static final String DEFAULT_SRPFILE = "/etc/ppp/srp-secrets";

	/** Entries cache */
	protected SoftReference<Map<String, PapEntry>> papRef = null;

	/** password crypted flag */
	protected boolean cryptedPasswd = false;

	/** Last modified file time */
	protected long fileTime = 0;

	/**
	 * Конструктор.
	 * 
	 * @param fileName
	 *            имя файла для работы.
	 * @throws FileLockedException
	 *             file locked
	 */
	protected PapFile(final String fileName) throws FileLockedException {
		super(fileName);
		this.cryptedPasswd = PapFile.DEFAULT_CHAPFILE.equals(this.getFileName());
		PapFile.log.info("using file: " + this.getFileName());
	}

	/**
	 * Возвращает экземпляр обьекта.
	 * 
	 * @param fileName
	 *            имя файда.
	 * @return экземпляр обьекта, связанный с данным файлом.
	 * @throws FileLockedException
	 *             file locked
	 */
	public static synchronized PapFile getInstance(final String fileName) throws FileLockedException {
		if (fileName == null) throw new IllegalArgumentException("null fileName");
		PapFile papFile = null;
		final SystemFileHandler handler = SystemFileHandler.getHandler(fileName);
		if (handler == null) papFile = new PapFile(fileName);
		else if (handler instanceof PapFile) papFile = (PapFile) handler;
		else throw new FileLockedException(fileName);
		return papFile;
	}

	/**
	 * Возвращает эеземпляр для {@link #DEFAULT_PAPFILE}.
	 * <P>
	 * Аналогичен {@link #getPapFile}.
	 * </P>
	 * 
	 * @return объект, связанный с {@value #DEFAULT_PAPFILE}.
	 * @throws FileLockedException
	 *             file locked
	 */
	public static synchronized PapFile getInstance() throws FileLockedException {
		return PapFile.getInstance(PapFile.DEFAULT_PAPFILE);
	}

	/**
	 * Возвращает эеземпляр для {@link #DEFAULT_PAPFILE}.
	 * 
	 * @return объект, связанный с {@value PapFile#DEFAULT_PAPFILE}.
	 * @throws FileLockedException
	 *             file locked
	 */
	public static synchronized PapFile getPapFile() throws FileLockedException {
		return PapFile.getInstance(PapFile.DEFAULT_PAPFILE);
	}

	/**
	 * Возвращает эеземпляр для {@link #DEFAULT_CHAPFILE}.
	 * 
	 * @return объект, связанный с {@value #DEFAULT_CHAPFILE}.
	 * @throws FileLockedException
	 *             file locked
	 */
	public static synchronized PapFile getChapFile() throws FileLockedException {
		return PapFile.getInstance(PapFile.DEFAULT_CHAPFILE);
	}

	/**
	 * Возвращает эеземпляр для {@link #DEFAULT_SRPFILE}.
	 * 
	 * @return объект, связанный с {@value #DEFAULT_SRPFILE}.
	 * @throws FileLockedException
	 *             file locked
	 */
	public static synchronized PapFile getSrpFile() throws FileLockedException {
		return PapFile.getInstance(PapFile.DEFAULT_SRPFILE);
	}

	/**
	 * Устанавливает флаг шифровки паролей.
	 * 
	 * @param crypted
	 *            true, если пароли должны шифроваться в файле.
	 */
	public synchronized void setCryptedPasswords(final boolean crypted) {
		this.cryptedPasswd = crypted;
		PapFile.log.info("encrypting passwords: " + crypted);
	}

	/**
	 * Возвращает флаг шифрования паролей.
	 * 
	 * @return true, если пароли шифруются.
	 */
	public synchronized boolean isCryptedPasswords() {
		return this.cryptedPasswd;
	}

	/**
	 * Читает и разбирает файл.
	 * 
	 * @throws IOException
	 *             ошибка чтения файла.
	 * @return элементы из файла.
	 */
	protected synchronized Map<String, PapEntry> getFileData() throws IOException {
		Map<String, PapEntry> entries = null;
		// try using cache
		if (this.papRef != null) entries = this.papRef.get();
		if (entries != null && this.fileTime != 0 && new File(this.getFileName()).lastModified() <= this.fileTime) return entries;
		// reset cache
		entries = new HashMap<>();
		this.fileTime = 0;
		this.papRef = null;
		// read file

		PapFile.log.trace("loading file: " + this.getFileName());
		try (final Reader fr = new FileReader(this.getFileName())) {
			entries = new PapFileParser().parse(fr);
			// need to close before check file modified time
			fr.close();
			// get file time
			this.fileTime = new File(this.getFileName()).lastModified();
			this.papRef = new SoftReference<>(entries);
		} catch (final FileNotFoundException ex) {
			PapFile.log.warn("pap-file '" + this.getFileName() + "' not accessible");
		}
		return entries;
	}

	/**
	 * Сохраняет файл на диск.
	 * 
	 * @param entries
	 *            элементы для записи.
	 * @throws IOException
	 *             ошибка записи файла.
	 */
	protected synchronized void saveFileData(final Map<String, PapEntry> entries) throws IOException {
		assert entries != null;
		// check file modified by other processed while read
		if (new File(this.getFileName()).lastModified() > this.fileTime) {
			PapFile.log.warn("pap-file '" + this.getFileName() + "' was modified by another process before save");
		}
		// save file
		PapFile.log.trace("saving data to file: " + this.getFileName());
		File tempFile = null;
		try (final PrintStream writer = new PrintStream(new BufferedOutputStream(new FileOutputStream(tempFile)));) {
			tempFile = new File(this.getFileName() + ".tmp." + System.currentTimeMillis());
			new PapFileParser().compile(entries.values(), writer);
			if (!tempFile.renameTo(new File(this.getFileName()))) throw new IOException(String.format(
					"unable to rename file '%s' to file '%s'", tempFile.getPath(), this.getFileName()));
			// get new time and reset modified
			this.fileTime = new File(this.getFileName()).lastModified();
			this.papRef = new SoftReference<>(entries);
		} finally {
			try {
				if (tempFile != null && tempFile.exists()) tempFile.delete();
			} catch (final Exception ex) {
				// NOP
			}
		}
	}

	/**
	 * Возвращает элементы.
	 * <P>
	 * Если в файле пароли зашифрованы, то возвращаемые элементы будут содержать
	 * зашифрованный пароль.
	 * </P>
	 * 
	 * @return элементы pap-файла.
	 * @throws IOException
	 *             ошибка чтения файла.
	 */
	public synchronized Collection<PapEntry> list() throws IOException {
		return this.getFileData().values();
	}

	/**
	 * Search entry with given name.
	 * 
	 * @param name
	 *            entry name
	 * @param server
	 *            entry server or null
	 * @return entry with given name and empty or given server or null if not
	 *         found
	 * @throws IOException
	 */
	public PapEntry search(final String name, final String server) throws IOException {
		if (name == null || name.length() < 1) throw new IllegalArgumentException("empty name");
		final Collection<PapEntry> entries = this.list();
		for (final PapEntry entry : entries) {
			if (entry.getName().equals(name) && (entry.getServer() == null || entry.getServer().equals(server))) return entry;
		}
		PapFile.log.trace("entry '" + name + "@" + server + "' not found in " + this.getFileName());
		return null;
	}

	/**
	 * Добавляет новый элемент.
	 * <P>
	 * Если используется шифрование, то перед добавлением записи пароль
	 * шифруется.
	 * </P>
	 * 
	 * @param name
	 *            имя пользователя или null.
	 * @param server
	 *            сервер или null.
	 * @param theSecret
	 *            пароль или null.
	 * @param ips
	 *            список разрешенных IP-адресов или {@link PapEntry#NONE} или
	 *            null.
	 * @throws DuplicateException
	 *             дублирование записей.
	 * @throws IOException
	 *             ошибка чтения/сохранения файла.
	 */
	public synchronized void add(final String name, final String server, final String theSecret, final String ips)
			throws DuplicateException, IOException {
		final String secret = theSecret != null && this.cryptedPasswd ? UnixCrypt.crypt(theSecret) : theSecret;
		final PapEntry entry = new PapEntry(name, server, secret, ips);
		final String key = entry.getKey();
		final Map<String, PapEntry> entries = this.getFileData();
		if (entries.containsKey(key)) throw new DuplicateException(PapEntry.makeKey(name, server));
		entries.put(key, entry);
		this.saveFileData(entries);
		PapFile.log.debug("added pap-entry: " + key + " in file: " + this.getFileName());
	}

	/**
	 * Переименовывает учетную запись.
	 * 
	 * @param name
	 *            имя записи или null.
	 * @param server
	 *            сервер или null.
	 * @param newname
	 *            String
	 * @throws NotFoundException
	 *             запись не найдена.
	 * @throws DuplicateException
	 *             новое имя@сервер уже существует.
	 * @throws IOException
	 *             ошибка ввода/вывода.
	 */
	public synchronized void rename(final String name, final String server, final String newname)
			throws NotFoundException, DuplicateException, IOException {
		// пропускаем операцию если старое и новое имя одинаковы
		if (name == null && newname == null || name != null && newname != null && name.equals(newname)) return;
		// получаем данные
		final Map<String, PapEntry> entryMap = this.getFileData();
		// проверяем наличие нового и старого имени
		final String keyold = PapEntry.makeKey(name, server);
		final String keynew = PapEntry.makeKey(newname, server);
		if (!entryMap.containsKey(keyold)) throw new NotFoundException("pap '" + keyold + "'");
		if (entryMap.containsKey(keynew)) throw new DuplicateException("pap '" + keynew + "'");
		// удаляем старую запись
		final PapEntry entry = entryMap.remove(keyold);
		// помещаем новую запись
		entryMap.put(keynew, new PapEntry(newname, server, entry.getSecret(), entry.getIps()));
		// сохраняем данные
		this.saveFileData(entryMap);
		PapFile.log.debug("renamed pap '" + keyold + "' to '" + keynew + "'");
	}

	/**
	 * Меняет пароль учетной записи.
	 * <P>
	 * Если режим шифрования {@link #isCryptedPasswords()} включен, то пароль
	 * будет зашифрован UnixCrypt.
	 * </P>
	 * 
	 * @param name
	 *            имя пользователя или null.
	 * @param server
	 *            сервер или null.
	 * @param secret
	 *            пароль или null.
	 * @throws NotFoundException
	 *             запись не найдена.
	 * @throws IOException
	 *             ошибка ввода/вывода.
	 */
	public synchronized void changeSecret(final String name, final String server, final String secret)
			throws NotFoundException, IOException {
		final String _secret = this.cryptedPasswd && secret != null ? UnixCrypt.crypt(secret) : secret;
		// получаем данные
		final Map<String, PapEntry> entryMap = this.getFileData();
		// проверяем существование
		final String key = PapEntry.makeKey(name, server);
		if (!entryMap.containsKey(key)) throw new NotFoundException("pap '" + key + "'");
		// проверяем необходимость смены
		final PapEntry entry = entryMap.get(key);
		if (_secret == null && entry.getSecret() == null || _secret != null && entry.getSecret() != null
				&& entry.getSecret().equals(_secret)) return;
		// замещаем запись
		entryMap.put(key, new PapEntry(name, server, _secret, entry.getIps()));
		// сохраняем
		this.saveFileData(entryMap);
		PapFile.log.debug("changed password of pap '" + key + "'");
	}

	/**
	 * Меняет IP-адреса записи.
	 * 
	 * @param name
	 *            имя пользователя или null.
	 * @param server
	 *            сервер или null.
	 * @param ips
	 *            IP-адреса или null (ANY) или '-' (NONE).
	 * @throws NotFoundException
	 *             запись не найдена.
	 * @throws IOException
	 *             ошибка ввода/вывода.
	 */
	public synchronized void changeIp(final String name, final String server, final String ips)
			throws NotFoundException, IOException {
		// получаем данные
		final Map<String, PapEntry> entryMap = this.getFileData();
		// проверяем существование
		final String key = PapEntry.makeKey(name, server);
		if (!entryMap.containsKey(key)) throw new NotFoundException("pap '" + key + "'");
		// проверяем необходимость замены
		final PapEntry entry = entryMap.get(key);
		if (ips == null && entry.getIps() == null || ips != null && entry.getIps() != null
				&& entry.getIps().equals(ips)) return;
		// замещаем
		entryMap.put(key, new PapEntry(name, server, entry.getSecret(), ips));
		// сохраняем
		this.saveFileData(entryMap);
		PapFile.log.debug("changed ip of pap '" + key + "' to '" + ips + "'");
	}

	/**
	 * Удаляет запись.
	 * 
	 * @param name
	 *            имя пользователя записи для удаления или null.
	 * @param server
	 *            сервер записи для удаления или null.
	 * @throws NotFoundException
	 *             запись не найдена.
	 * @throws IOException
	 *             ошибка чтения/сохранения файла.
	 */
	public synchronized void remove(final String name, final String server) throws NotFoundException, IOException {
		final PapEntry entry = new PapEntry(name, server, null, null);
		final String key = entry.getKey();
		final Map<String, PapEntry> entries = this.getFileData();
		if (!entries.containsKey(key)) throw new NotFoundException(PapEntry.makeKey(name, server));
		entries.remove(key);
		this.saveFileData(entries);
		PapFile.log.debug("removed pap-entry: " + key + " in file: " + this.getFileName());
	}
}
